import gin
import tensorflow as tf
import numpy as np

from tener.misc.pretty_print import print_error, print_info, print_warn
from tener.models.model_utils import create_masks
from tener.utils import CustomSchedule

def scaled_dot_product_attention(q, k, v, mask):
    """Calculate the attention weights.
    q, k, v must have matching leading dimensions.
    k, v must have matching penultimate dimension, i.e.: seq_len_k = seq_len_v.
    The mask has different shapes depending on its type(padding or look ahead)
    but it must be broadcastable for addition.

    Args:
      q: query shape == (..., seq_len_q, depth)
      k: key shape == (..., seq_len_k, depth)
      v: value shape == (..., seq_len_v, depth_v)
      mask: Float tensor with shape broadcastable
            to (..., seq_len_q, seq_len_k). Defaults to None.

    Returns:
      output, attention_weights
    """

    matmul_qk = tf.matmul(q, k, transpose_b=True)  # (..., seq_len_q, seq_len_k)

    # scale matmul_qk
    dk = tf.cast(tf.shape(k)[-1], tf.float32)
    scaled_attention_logits = matmul_qk / tf.math.sqrt(dk)

    # add the mask to the scaled tensor.
    if mask is not None:
        scaled_attention_logits += (mask * -1e9)

        # softmax is normalized on the last axis (seq_len_k) so that the scores
    # add up to 1.
    attention_weights = tf.nn.softmax(scaled_attention_logits, axis=-1)  # (..., seq_len_q, seq_len_k)

    output = tf.matmul(attention_weights, v)  # (..., seq_len_q, depth_v)

    return output, attention_weights


def point_wise_feed_forward_network(d_model, dff):
    return tf.keras.Sequential([
        tf.keras.layers.Dense(dff, activation='relu'),  # (batch_size, seq_len, dff)
        tf.keras.layers.Dense(d_model)  # (batch_size, seq_len, d_model)
    ])


def get_angles(pos, i, d_model):
    angle_rates = 1 / np.power(10000, (2 * (i//2)) / np.float32(d_model))
    return pos * angle_rates


def positional_encoding(position, d_model):
    angle_rads = get_angles(np.arange(position)[:, np.newaxis],
                            np.arange(d_model)[np.newaxis, :],
                            d_model)

    # apply sin to even indices in the array; 2i
    angle_rads[:, 0::2] = np.sin(angle_rads[:, 0::2])

    # apply cos to odd indices in the array; 2i+1
    angle_rads[:, 1::2] = np.cos(angle_rads[:, 1::2])

    pos_encoding = angle_rads[np.newaxis, ...]

    return tf.cast(pos_encoding, dtype=tf.float32)

# ----------------------------------------------------------------------------------------------------------------------


class MultiHeadAttention(tf.keras.layers.Layer):
    def __init__(self, d_model, num_heads):
        super(MultiHeadAttention, self).__init__()
        self.num_heads = num_heads
        self.d_model = d_model

        assert d_model % self.num_heads == 0

        self.depth = d_model // self.num_heads

        self.wq = tf.keras.layers.Dense(d_model)
        self.wk = tf.keras.layers.Dense(d_model)
        self.wv = tf.keras.layers.Dense(d_model)

        self.dense = tf.keras.layers.Dense(d_model)

    def split_heads(self, x, batch_size):
        """Split the last dimension into (num_heads, depth).
        Transpose the result such that the shape is (batch_size, num_heads, seq_len, depth)
        """
        x = tf.reshape(x, (batch_size, -1, self.num_heads, self.depth))
        return tf.transpose(x, perm=[0, 2, 1, 3])

    def call(self, v, k, q, mask):
        batch_size = tf.shape(q)[0]

        q = self.wq(q)  # (batch_size, seq_len, d_model)
        k = self.wk(k)  # (batch_size, seq_len, d_model)
        v = self.wv(v)  # (batch_size, seq_len, d_model)

        q = self.split_heads(q, batch_size)  # (batch_size, num_heads, seq_len_q, depth)
        k = self.split_heads(k, batch_size)  # (batch_size, num_heads, seq_len_k, depth)
        v = self.split_heads(v, batch_size)  # (batch_size, num_heads, seq_len_v, depth)

        # scaled_attention.shape == (batch_size, num_heads, seq_len_q, depth)
        # attention_weights.shape == (batch_size, num_heads, seq_len_q, seq_len_k)
        scaled_attention, attention_weights = scaled_dot_product_attention(
            q, k, v, mask)

        scaled_attention = tf.transpose(scaled_attention,
                                        perm=[0, 2, 1, 3])  # (batch_size, seq_len_q, num_heads, depth)

        concat_attention = tf.reshape(scaled_attention,
                                      (batch_size, -1, self.d_model))  # (batch_size, seq_len_q, d_model)

        output = self.dense(concat_attention)  # (batch_size, seq_len_q, d_model)

        return output, attention_weights

# ----------------------------------------------------------------------------------------------------------------------


class EncoderLayer(tf.keras.layers.Layer):
    def __init__(self, d_model, num_heads, dff, rate=0.1):
        super(EncoderLayer, self).__init__()

        self.mha = MultiHeadAttention(d_model, num_heads)
        self.ffn = point_wise_feed_forward_network(d_model, dff)

        self.layernorm1 = tf.keras.layers.LayerNormalization(epsilon=1e-6)
        self.layernorm2 = tf.keras.layers.LayerNormalization(epsilon=1e-6)

        self.dropout1 = tf.keras.layers.Dropout(rate)
        self.dropout2 = tf.keras.layers.Dropout(rate)

    def call(self, x, training, mask):
        attn_output, _ = self.mha(x, x, x, mask)  # (batch_size, input_seq_len, d_model)
        attn_output = self.dropout1(attn_output, training=training)
        out1 = self.layernorm1(x + attn_output)  # (batch_size, input_seq_len, d_model)

        ffn_output = self.ffn(out1)  # (batch_size, input_seq_len, d_model)
        ffn_output = self.dropout2(ffn_output, training=training)
        out2 = self.layernorm2(out1 + ffn_output)  # (batch_size, input_seq_len, d_model)

        return out2

# ----------------------------------------------------------------------------------------------------------------------


class DecoderLayer(tf.keras.layers.Layer):
    def __init__(self, d_model, num_heads, dff, rate=0.1):
        super(DecoderLayer, self).__init__()

        self.mha1 = MultiHeadAttention(d_model, num_heads)
        self.mha2 = MultiHeadAttention(d_model, num_heads)

        self.ffn = point_wise_feed_forward_network(d_model, dff)

        self.layernorm1 = tf.keras.layers.LayerNormalization(epsilon=1e-6)
        self.layernorm2 = tf.keras.layers.LayerNormalization(epsilon=1e-6)
        self.layernorm3 = tf.keras.layers.LayerNormalization(epsilon=1e-6)

        self.dropout1 = tf.keras.layers.Dropout(rate)
        self.dropout2 = tf.keras.layers.Dropout(rate)
        self.dropout3 = tf.keras.layers.Dropout(rate)

    def call(self, x, enc_output, training,
             look_ahead_mask, padding_mask):
        # enc_output.shape == (batch_size, input_seq_len, d_model)

        attn1, attn_weights_block1 = self.mha1(x, x, x, look_ahead_mask)  # (batch_size, target_seq_len, d_model)
        attn1 = self.dropout1(attn1, training=training)
        out1 = self.layernorm1(attn1 + x)

        attn2, attn_weights_block2 = self.mha2(
            enc_output, enc_output, out1, padding_mask)  # (batch_size, target_seq_len, d_model)
        attn2 = self.dropout2(attn2, training=training)
        out2 = self.layernorm2(attn2 + out1)  # (batch_size, target_seq_len, d_model)

        ffn_output = self.ffn(out2)  # (batch_size, target_seq_len, d_model)
        ffn_output = self.dropout3(ffn_output, training=training)
        out3 = self.layernorm3(ffn_output + out2)  # (batch_size, target_seq_len, d_model)

        return out3, attn_weights_block1, attn_weights_block2

# ----------------------------------------------------------------------------------------------------------------------


class Encoder(tf.keras.layers.Layer):
    def __init__(self,
                 num_layers,
                 d_model,
                 num_heads,
                 dff,
                 input_vocab_size,
                 maximum_position_encoding,
                 rate=0.1):
        super(Encoder, self).__init__()

        self.d_model = d_model
        self.num_layers = num_layers

        self.embedding = tf.keras.layers.Embedding(input_vocab_size, d_model)
        self.pos_encoding = positional_encoding(maximum_position_encoding,
                                                self.d_model)

        self.enc_layers = [EncoderLayer(d_model, num_heads, dff, rate)
                           for _ in range(num_layers)]

        self.dropout = tf.keras.layers.Dropout(rate)

    def call(self, x, training, mask):
        seq_len = tf.shape(x)[1]
        # print("enc: seq_len : {}".format(seq_len))

        # adding _word_embedding and position encoding.
        x = self.embedding(x)  # (batch_size, input_seq_len, d_model)
        # print("enc: _word_embedding {}".format(x.shape))
        x *= tf.math.sqrt(tf.cast(self.d_model, tf.float32))
        # print("dec sin_pos_encoding : {}".format(self.pos_encoding.shape))
        pos = self.pos_encoding[:, :seq_len, :]
        x += pos

        x = self.dropout(x, training=training)

        for i in range(self.num_layers):
            x = self.enc_layers[i](x, training, mask)

        # print("enc: out {}".format(x.shape))

        return x  # (batch_size, input_seq_len, d_model)

# ----------------------------------------------------------------------------------------------------------------------


class Decoder(tf.keras.layers.Layer):
    def __init__(self,
                 num_layers,
                 d_model,
                 num_heads,
                 dff,
                 target_vocab_size,
                 maximum_position_encoding,
                 rate=0.1):
        super(Decoder, self).__init__()

        # print(">>>>>>>>>>>", maximum_position_encoding, d_model)
        self.d_model = d_model
        self.num_layers = num_layers

        self.embedding = tf.keras.layers.Embedding(target_vocab_size, d_model)
        self.pos_encoding = positional_encoding(maximum_position_encoding, d_model)

        self.dec_layers = [DecoderLayer(d_model, num_heads, dff, rate)
                           for _ in range(num_layers)]
        self.dropout = tf.keras.layers.Dropout(rate)

    def call(self, x, enc_output, training,
             look_ahead_mask, padding_mask):
        seq_len = tf.shape(x)[1]
        attention_weights = {}

        # print("dec seq_length : {}".format(seq_len))

        x = self.embedding(x)  # (batch_size, target_seq_len, d_model)
        # print("dec: _word_embedding {}".format(x.shape))
        x *= tf.math.sqrt(tf.cast(self.d_model, tf.float32))
        # print("dec x : {}".format(x.shape))
        pos = self.pos_encoding[:, :seq_len, :]
        # print("dec sin_pos_encoding : {}".format(self.pos_encoding.shape))
        # print("dec pos : {}".format(pos.shape))
        x = x + pos
        x = self.dropout(x, training=training)

        for i in range(self.num_layers):
            x, block1, block2 = self.dec_layers[i](x, enc_output, training, look_ahead_mask, padding_mask)

            attention_weights['decoder_layer{}_block1'.format(i + 1)] = block1
            attention_weights['decoder_layer{}_block2'.format(i + 1)] = block2

        # print("dec: out {}".format(x.shape))
        # print("dec: attention_weights {}".format(attention_weights))

        # x.shape == (batch_size, target_seq_len, d_model)
        return x, attention_weights

# ----------------------------------------------------------------------------------------------------------------------

class VanillaTransformer(tf.keras.Model):
    def __init__(self,
                 num_layers,
                 d_model,
                 num_heads,
                 dff,
                 input_vocab_size,
                 target_vocab_size,
                 pos_inp_emb_max_index,
                 pos_tar_emb_max_index,
                 rate=0.1):
        super(VanillaTransformer, self).__init__()

        self.encoder = Encoder(num_layers,
                               d_model,
                               num_heads,
                               dff,
                               input_vocab_size,
                               pos_inp_emb_max_index,
                               rate)

        self.decoder = Decoder(num_layers,
                               d_model,
                               num_heads,
                               dff,
                               target_vocab_size,
                               pos_tar_emb_max_index,
                               rate)

        self.final_layer = tf.keras.layers.Dense(target_vocab_size)

    def call(self, inp, tar, training, enc_padding_mask,
             look_ahead_mask, dec_padding_mask):
        enc_output = self.encoder(inp, training, enc_padding_mask)  # (batch_size, inp_seq_len, d_model)
        # print(">>> Trans: enc_output : {}".format(enc_output.shape))
        # dec_output.shape == (batch_size, tar_seq_len, d_model)
        dec_output, attention_weights = self.decoder(tar, enc_output, training, look_ahead_mask, dec_padding_mask)
        # print(">>> Trans: dec_output : {}".format(dec_output.shape))
        final_output = self.final_layer(dec_output)  # (batch_size, tar_seq_len, target_vocab_size)
        # print(">>> Trans: final_output : {}".format(final_output.shape))
        return final_output, attention_weights

# ----------------------------------------------------------------------------------------------------------------------

train_step_signature = [
    tf.TensorSpec(shape=(None, None), dtype=tf.int64),
    tf.TensorSpec(shape=(None, None), dtype=tf.int64),
]

@gin.configurable
class VanillaTransformerModel(object):
    def __init__(self,
                 input_vocab_size,
                 target_vocab_size,
                 pos_inp_emb_max_index=1000,
                 pos_tar_emb_max_index=1000,
                 num_layers=4,
                 d_model=128,
                 num_heads=8,
                 dff=512,
                 rate=0.1):
        self._d_model = d_model
        self._transformer = VanillaTransformer(num_layers=num_layers,
                                               d_model=d_model,
                                               num_heads=num_heads,
                                               dff=dff,
                                               input_vocab_size=input_vocab_size,
                                               target_vocab_size=target_vocab_size,
                                               pos_inp_emb_max_index=pos_inp_emb_max_index,
                                               pos_tar_emb_max_index=pos_tar_emb_max_index,
                                               rate=0.1)

        self._train_loss = tf.keras.metrics.Mean(name='train_loss')
        self._train_accuracy = tf.keras.metrics.SparseCategoricalAccuracy(name='train_accuracy')

    def _loss(self, real, pred):
        loss_object = tf.keras.losses.SparseCategoricalCrossentropy(
            from_logits=True, reduction='none')
        mask = tf.math.logical_not(tf.math.equal(real, 0))
        loss_ = loss_object(real, pred)

        mask = tf.cast(mask, dtype=loss_.dtype)
        loss_ *= mask

        return tf.reduce_mean(loss_)

    def _learning_rate(self):
        return CustomSchedule(self._d_model)

    def _optimizer(self):
        learning_rate = self._learning_rate()
        return tf.keras.optimizers.Adam(learning_rate, beta_1=0.9, beta_2=0.98,
                                        epsilon=1e-9)

    def ckpt(self):
        return tf.train.Checkpoint(transformer=self._transformer,
                                   optimizer=self._optimizer())

    # @tf.function(input_signature=train_step_signature)
    def train_step(self, inp, tar, is_training=False, is_log=False, text_tokenizer=None, tag_tokenizer=None):
        tar_inp = tar[:, :-1]
        tar_real = tar[:, 1:]

        enc_padding_mask, combined_mask, dec_padding_mask = create_masks(inp["word_ids"], tar_inp)

        with tf.GradientTape() as tape:
            predictions, _ = self._transformer(inp["word_ids"],
                                               tar_inp,
                                               True,
                                               enc_padding_mask,
                                               combined_mask,
                                               dec_padding_mask)
            loss = self._loss(tar_real, predictions)
            # print("!!!!! trainer : {}".format(loss))

        gradients = tape.gradient(loss, self._transformer.trainable_variables)
        self._optimizer().apply_gradients(zip(gradients, self._transformer.trainable_variables))

        self._train_loss(loss)
        self._train_accuracy(tar_real, predictions)

        if is_log:
            # print_info("Input : {} {}".format(inp["word_ids"], inp["word_ids"].shape))
            # print_info("Input : {} {}".format(inp[0], inp[1].shape))
            # print_info("Target : {}".format(tar))
            # print_info("Predictions : {}".format(predictions))
            print_error(tag_tokenizer.word_index)
            if text_tokenizer and tag_tokenizer:
                texts = text_tokenizer.sequences_to_texts(inp[0].numpy())
                actual_tags = tag_tokenizer.sequences_to_texts(tar.numpy())
                pred_tags = tag_tokenizer.sequences_to_texts(predictions.numpy())

                for text, actual_tag, pred_tag, pred_id in zip(texts, actual_tags, pred_tags, predictions.numpy()):
                    print_info("Text: {}".format(text))
                    print_warn("ATag: {}".format(actual_tag))
                    print_warn("PTag: {}".format(pred_tag))
                    print_warn("PTag: {}".format(pred_id))
                    print("\n")